本节将会对自适应运行时环境中线程和同步操作方面的优化进行介绍。
正如之前介绍锁时提到的,自适应运行时系统需要能够当前系统负载和线程竞争情况在胖锁和瘦锁之间切换执行,这里就涉及到代码生成器和锁的具体实现。
在自适应环境中可以以很小的开销得到锁的运行信息(如果要做完整的锁分析则会有一些性能开销),当线程获取/释放某个锁时,运行时系统可以记录下是哪个线程获得了锁,获取锁时的竞争情况。所以,如果某个线程尝试了很多次还无法获取到锁,运行时就可以考虑将该瘦锁调整为胖锁。胖锁更适合竞争激烈的场景,不再让线程自旋,而挂起被阻塞住的线程,这样可以节省对CPU资源的浪费。这种瘦锁到胖锁的转换称为 锁膨胀(lock inflation)。
默认情况下,即使瘦锁已经膨胀为胖锁,JRockit也是使用一个小的自旋锁来实现胖锁的。乍看之下,这不太符合常理,但这么做确实是很有益处的。如果锁的竞争确实非常激烈,而导致线程长时间自旋的话,可以使用命令行参数
-XX:UseFatSpin=false
可以禁用此方式。作为胖锁的一部分,自旋锁也可以利用到自适应运行时获取到的反馈信息,这部分功能默认是禁用的,可以使用命令行参数-XX:UseAdaptiveFatSpin=true
来开启。
类似的,在完成了一系列解锁操作之后,如果锁队列和等待队列中都是空的,这时就可以考虑将胖锁再转换为瘦锁了,这称为 锁收缩(lock deflation)。
JRockit使用了启发式算法来执行锁膨胀和锁收缩,因此对于某个应用程序来说,锁的行为会根据线程对锁的竞争情况而改变。
如果需要的话,可以通过命令行参数来改变用于切换胖锁和瘦锁的启发式算法,但通常不建议这样做,下一章会对此做简要介绍。
同一个线程可以对同一个对象加锁数次,这就是所谓的 递归锁(recursive lock),尽管没必要,但这么做确实是合法的,例如,当某个会执行加锁操作的方法被内联到对同一个对象加锁的方法中,或者某个同步方法被递归调用,这时就会出现递归锁。如果关键区代码中没有危险代码(例如在内部锁和外部锁之间访问volatile
变量,或发生对象逃逸),则代码生成器可以考虑将内部的锁彻底移除。
JRockit使用了一个专门的锁符号位(lock token bit)来标识递归锁。当某个锁被某个线程获取到至少两次以上,而且没有释放最外层的锁,则该锁会被标记为递归锁。当发生异常时,运行时会重置递归标记,正确抛出异常,不会带来什么额外的同步操作的开销。
在JRockit中,JIT优化编译器还使用了一种名为 锁融合(lock fusion)的代码优化技术,在某些文档中也称为 锁粗化(lock coarsening)。当编译器将很多方法内联到一起后,尤其是将多个同步方法内联到一起后,可能会出现多个代码块按顺序对同一个监视器对象重复执行加锁和解锁操作。
Consider code that, after inlining, looks like: 以下面的代码为例:
synchronized(x) {
//Do something...
}
//Short snippet of code...
x = y;
synchronized(y) {
//Do something else...
}
别名分析可以判断出x
和y
实际上是同一个对象。如果两个同步代码块之间的代码的执行开销非常小,而且比释放锁再获取锁的开销还小的话,则代码生成器就可以考虑将两个同步代码块合并到一起,如下所示:
synchronized(x) {
//Do something...
//Short snippet of code...
x = y;
//Do something else...
}
当然,执行锁融合的前提是两个同步代码块之间的代码中不能有对volatile
变量的访问,也不能发生对象逃逸,更不能使融合后的代码违反Java内存模型的语义。除了上述问题之外,还有一些其他可能因优化产生的问题需要处理,例如锁融合后对异常的处理需要考虑相互之间的兼容性,因其超出本章范围,不再赘述。
将所有的代码块都融合到一起显然不是什么好主意,但如果能够正确的挑选必要的代码块进行如何的话,还是很有裨益的。如果能过获得足够多的采样信息,就能够更准确的判断是否要执行融合锁操作。
总归一句话,上述代码优化的主要目的是避免不必要的锁释放操作。其实,不借助代码生成器,线程系统本身可以通过状态机来实现类似的优化,即所谓的 延迟解锁(lazy unlocking)。
当系统中有很多会降低程序执行效率的、线程局部的解锁和重新加锁的操作时,会有什么影响?这是否是程序运行的常态?运行时是否可以假设每个单独的解锁操作实际上都是不必要的?
如果某个锁每次被释放后又立刻都被同一个线程获取到,则运行时可以做上述假设。但只要有另外某个线程试图获取这个看起来像是未被加锁的监视器对象(这种情况是符合语义的),这种假设就不再成立了。这时为了使这个监视器对象看起来 像是一切正常,原本持有该监视器对象的线程需要强行释放该锁。这种实现方式称为 延迟解锁(lazy unlocking),在某些描述中也称为 偏向锁(biased locking)。
即使某个锁完全没有竞争,执行加锁和解锁操作的开销仍旧比什么都不做要大。而使用原子指令会使该指令周围的Java代码都产生额外的执行开销。
在Java环境中,有时确实可以假设大部分锁都只在线程局部内起作用。第三方代码为了完成线程局部内的操作有时会使用不必要的同步操作,这是因为第三方库的作者无法知晓其代码是否会被用在并行环境中,除非显式的指定代码不是线程安全的,否则就不得不使用同步操作。JDK本身也有很多这样的例子,典型的就是java.util.Vector
类的实现。如果程序员要在线程局部环境中使用向量,但却没有考虑清楚的话,就有可能会使用java.util.Vector
类,事实上,java.util.ArrayList
类可以完成同样的任务,还不会有同步操作带来的额外开销。
从上面的介绍可以看出,假设大部分锁都只在线程局部起作用而不会出现竞争情况,是有道理的,在这种情况下,使用延迟解锁的优化方式是可以提升系统性能的。当然,天下没有免费的午餐,如果某个线程试图获取某个已经延迟解锁优化的监视器对象,这时的执行开销会被直接获取普通监视器对象大得多,因为这个看似未加锁的监视器对象必须要先被强行释放掉。
因此,假设解锁操作不再必要并不总是正确的,需要对不同的运行时行为做针对性的优化。
实现延迟解锁的语义其实很简单。
实现monitorenter
指令:
实现monitorexit
指令:
为了能解除线程对锁的持有状态,必须要先暂停该线程的执行,这个操作有不小的开销。在释放锁之后,锁的实际状态会通过检查线程栈中的锁符号来确定,这种处理方式与之前介绍的处理不匹配的锁相同。延迟解锁使用自己的锁符号,以表示 "该对象是被延迟锁定的"。
如果延迟锁定的对象从来也没有被撤销过,即所有的锁都只在线程局部内发挥作用,那么使用延迟锁定就可以大幅提升系统性能。但在实际应用中,如果我们的假设不成立,运行时就不得不一边又一遍的释放已经被延迟加锁的对象,这种性能消耗实在承受不起。因此,运行时需要记录下监视器对象被不同线程获取到的次数,这部分信息存储在监视器对象的锁字中,称为 转移位(transfer bits)。
如果监视器对象在不同的线程之间 转移的次数过多,那么该对象、其类对象或者其类的所有实例都可能会被禁用延迟加锁,只会使用标准的胖锁和瘦锁来处理加锁/解决操作。
当监视器对象在不同的线程之间 转移的次数达到某个阈值后,运行时会设置该对象锁字中的 禁用标记位(forbid bit)。该标记位用于标记该对象是否可以用于延迟解锁。如果置位,则该对象不可再用于延迟解锁。
此外,如果锁的竞争非常激烈,则不管具体如何设置,都已经改禁用该对象的延迟解锁。此后,该对象加锁操作会按照胖锁和瘦锁处理。
仅仅禁止某个对象用于延迟解锁有时还不太够,同一类型的实例作为锁使用时通常具有类型的使用模式,因此,直接禁止该类用于延迟解锁就可以把它所有的实例都禁用了。如果某个类的实例作为锁使用时在不同线程之间转移的次数太多,或者某个有太多的实例被禁用于延迟解锁,则该类会被禁用。
运行时会记录类和某个实例被设置 禁用标记位的时,当禁用时间超过某个阈值后,会重新尝试启用延迟解锁。如果此后,发现该类或该对象又被禁用了,则重新开始计时,但这次阈值可能会变得更大一些,也可能会被永久禁用。
下图展示了不同锁状态之间的转换:
在上图中,有三种锁类型,其中胖锁和瘦锁在之前的小节中介绍过,这里新增了延迟锁(lazy lock),用来解释锁在大部分情况下都只作用于线程局部场景下的情况。
正如之前介绍过的,对象首先是未加锁状态的,然后线程 T1执行monitorenter
指令,使之进入延迟加锁状态。但如果线程 T1在该对象上执行了monitorexit
指令,这时系统会假装已经解锁了,但实际上仍是锁定状态,锁对象的锁字中仍记录着线程 T1的线程ID。在此之后,线程 T1如果再执行加锁操作,就不用再执行相关操作了。
如果另一个线程 T2试图获取同一个锁,则之前所做该锁绝大部分被线程 T1使用的假设不再成立,于是乎受到性能惩罚,将锁字中的线程ID由 线程T1的ID替换为线程 T2的。如果这种情况经常出现,那么可以考虑禁用该对象作为延迟锁使用,并将该对象作为普通的瘦锁使用。假设这是线程 T2第一次在该对象上调用monitorenter
指令,则程序会进入瘦锁控制流程。在上图中,被禁用于延迟解锁的对象用星号()做了标记。此时,当线程 *T3试图在某个已被禁用于延迟解锁的对象上加锁,如果该对象还未被锁定,则此时仍会使用瘦锁。
使用瘦锁时,如果竞争激烈,或者在锁对象上调用了wait
方法或notify
方法,则瘦锁会膨胀为胖锁,需要使用等待队列来处理。从图中可以看到,处于处于延迟解锁状态的对象直接调用wait
方法或notify
方法的话,也会膨胀为胖锁。
大部分商业JVM实现都在不同程度上使用了延迟解锁机制。有些讽刺的是,之所以会这样,可能是因为常用的 SPECjbb2005基准测试中包含大量线程局部的锁,为了能在性能测试中取得良好的成绩,而特意做了很多优化。
SPEC,即 Standard Performance Evaluation Corporation,是该组织的注册商标,而 SPECjbb2005则是其退出的性能测试工具。
但事实上,在很多应用程序中,使用延迟解锁是会有可能提升系统性能的。这是因为应用程序的复杂性和各种抽象层的存在,使用开发人员难以判断是否真的有必要使用同步操作。
在JRockit的某些版本中,例如在x86平台上为JDK 1.6.0实现的版本,默认开启了延迟解锁和根据启发式算法禁用对象(或类)的功能,用户可以用过命令行参数关闭这两个功能。要想查看某个JRockit版本是否默认启用了延迟解锁,请查看JRockit的相关文档。